Основной целью данного исследования является оценка результатов А/В - теста.
Импортируем нужные библиотеки.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats as st
from plotly import graph_objects as go
import math as mth
Сохраняем все датасеты.
marketing_events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
users = pd.read_csv('/datasets/final_ab_new_users.csv')
ab_events = pd.read_csv('/datasets/final_ab_events.csv')
ab_participants = pd.read_csv('/datasets/final_ab_participants.csv')
Проверим датасеты на пропуски, дубликаты и посмотрим типы данных.
marketing_events.info()
marketing_events.duplicated().sum()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes
0
В датасете отсутствуют дубликаты и пропущенные значения, но необходимо преобразовать типы данных в дву столбцах с датами.
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'])
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'])
marketing_events.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null datetime64[ns] 3 finish_dt 14 non-null datetime64[ns] dtypes: datetime64[ns](2), object(2) memory usage: 576.0+ bytes
users.info()
users.duplicated().sum()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
0
Пропущенных значений, как и дубликатов, не обнаружено. Изменим тип данных в столбце с датой.
users['first_date'] = pd.to_datetime(users['first_date'])
ab_events.info()
ab_events.duplicated().sum()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
0
ab_events['event_dt'] = pd.to_datetime(ab_events['event_dt']) #меняем тип данных в столбце с датой
Дубликатов не обнаружено, но в столбце details много пропущенных значений. Посмотрим, какие существуют типы событий и дополнительные данные о событии.
print(ab_events['event_name'].unique())
print(ab_events['details'].unique())
['purchase' 'product_cart' 'product_page' 'login'] [ 99.99 9.99 4.99 499.99 nan]
Очень похоже, что в столбце details хранится информация только о стоимости покупок. Проверим эту теорию.
ab_events[ab_events['event_name'] == 'purchase']['details'].count()
62740
Делаем вывод, что в столбце details хранится информация только о сумме покупки. Остальные события никакими дополнительными данными не обладают. Отсутсвующих значений очень много, поэтому удалять их не будем. Заменять тоже не будем, так как непонятно, каким образом это сделать.
ab_participants.info()
ab_participants.duplicated().sum()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB
0
Отсутствующих значений и дубликатов нет. Типы данных заменять не будем.
В таблице участников тестов проведем проверку на пересечения пользователей в группах и удалим данные участников конкурирующего теста, так как мы не обладаем информацией о возможном влиянии другого теста на этот. Можно было бы посмотреть на распределение пользователей групп А и Б между смежными тестами, но вероятность равномерно распределения очень мала, поэтому просто удалим данные.
group_a = np.array([ab_participants[ab_participants['group']=='A']['user_id']])
group_b = np.array([ab_participants[ab_participants['group']=='B']['user_id']])
c = np.intersect1d(group_a, group_b)
ab_participants = ab_participants[np.logical_not(ab_participants['user_id'].isin(c))]
ab_participants = ab_participants[ab_participants['ab_test']=='recommender_system_test']
Проверим соответсвие дат.
plt.figure(figsize=(10,5))
users['first_date'].hist()
plt.xlabel('Дата')
plt.title('Дата регистрации новых пользователей')
plt.show()
Дата запуска соответствует ТЗ, а дата остановки набора новых пользователей - нет. По ТЗ дата остановки: 2020-12-21, а в данных есть пользователи, зарегестрировавшиеся 23-12-2020. Это критичный для иссследования момент, поэтому удалим таких пользователей.
users = users[users['first_date'] <= '2020-12-21']
Посмотрим на даты совершенных событий.
plt.figure(figsize=(10,5))
ab_events['event_dt'].hist()
plt.xlabel('Дата')
plt.title('Cобытия новых пользователей')
plt.show()
Видим, что на момент 01-01-2020 пользователи не совершали никаких событий. Получается, что некоторые из зарегестрировавшихся пользователей не соответствуют тредованиям ТЗ о четырнадцатидневном лайфтайме. Это может негативно сказаться на результатах теста.
Скорее всего, на активность повлияли новогодние праздники. Можно сделать предположение, что проводить А/В - тестирование в преддверии праздников - не самая лучшая идея, т.к. это событие может сильно влиять на поведение пользователей.
Сразу же проверим, не совпадает ли время проведения теста с другими маркетинговыми событиями.
display(marketing_events[marketing_events['start_dt']>='2020-12-07'])
print('Исследуемые регионы:', users['region'].unique())
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
Исследуемые регионы: ['EU' 'N.America' 'APAC' 'CIS']
Во время проведения А/В - теста в Евросоюзе проводилась акция, связанная с новогодними праздниками. Второй повод задуматься о качестве результатов тестирования новой платежной воронки.
Посмотрим на отношение количества пользователей по регионам.
users.groupby('region').agg({'user_id':'count'}) / users['user_id'].count()
| user_id | |
|---|---|
| region | |
| APAC | 0.051054 |
| CIS | 0.051355 |
| EU | 0.749779 |
| N.America | 0.147813 |
Пользователи из Евросоюза составляют почти 75% от всех, кто зарегестрировался в период с 7 по 21 декабря 2020 года.
Объединим таблицы и еще раз посмотрим на количество пользователей из Евросоюза.
ab_group = ab_participants.merge(users, how = 'left', on = 'user_id')
ab_group = ab_group.merge(ab_events, how='left', on = 'user_id')
display(ab_group.groupby('region').agg({'user_id':'nunique'}) / ab_group['user_id'].nunique())
print('Количество уникальных пользователей:', ab_group['user_id'].nunique())
| user_id | |
|---|---|
| region | |
| APAC | 0.012152 |
| CIS | 0.009283 |
| EU | 0.940928 |
| N.America | 0.037637 |
Количество уникальных пользователей: 5925
В итоговой таблице количество пользователей из EU составляет 12% от количества всех новых зарегестрированных пользователей из EU.
ab_group.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 24608 entries, 0 to 24607 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 24608 non-null object 1 group 24608 non-null object 2 ab_test 24608 non-null object 3 first_date 24608 non-null datetime64[ns] 4 region 24608 non-null object 5 device 24608 non-null object 6 event_dt 21917 non-null datetime64[ns] 7 event_name 21917 non-null object 8 details 2965 non-null float64 dtypes: datetime64[ns](2), float64(1), object(6) memory usage: 1.9+ MB
Мы видим, что event_dt появились пропуски, это означает, что в тест попали пользователи, которые зарегестрировались, но никаких действий в системе больше не совершали. Посмотрим на распределение этих пользователей между группами и на основании получаенных результатов примем решение, что с ними делать.
ab_group[ab_group['event_dt'].isna()].groupby('group').agg({'user_id':'nunique', 'first_date':'nunique'})
| user_id | first_date | |
|---|---|---|
| group | ||
| A | 963 | 7 |
| B | 1728 | 15 |
ab_group[ab_group['event_dt'].isna()].groupby('group')['first_date'].hist(figsize=(10,6),
alpha = 0.5,
legend = True)
plt.title('Динамика набора пользователей')
plt.xlabel('Дата')
plt.show()
На графике видно, что до 13-12-2020 распределение таких пользователей было более или менее равномерным, но затем в группе А неактивные пользователи исчезли, а в группе В они все еще присутствуют, внося серьезный дисбаланс. На естественный отсев это не похоже.
Заменим лайфтайм на нулевое значение, чтобы не потерять данные при последующей фильтрации.
Посчитаем лайфтаймы событий.В ТЗ указано, что нужно учитывать события, которые были совершены пользователями в первые 14 дней с момена регистрации. Пропущенные значение заменим на ноль, чтобы не потерять данные. Отфильтруем датасет, чтобы в нем остались только те юзеры, лайфтайм которых не превышает 14 дней.
ab_group['lifetime'] = (ab_group['event_dt'] - ab_group['first_date']).dt.days
ab_group['lifetime'] = ab_group['lifetime'].fillna(0)
ab_group = ab_group[ab_group['lifetime'] <=14]
print('Количество уникальных пользователей в датасете:', ab_group['user_id'].nunique())
print('Количество пользователей в группе А:', ab_group[ab_group['group'] == 'A']['user_id'].nunique())
print('Количество пользователей в группе В:', ab_group[ab_group['group'] == 'B']['user_id'].nunique())
Количество уникальных пользователей в датасете: 5925 Количество пользователей в группе А: 3385 Количество пользователей в группе В: 2540
Подводя итог, можно сделать вывод о некорректном проведении теста. Пользователи пересекаются с конкурирующим тестом и нет достоверной информации о влиянии этого теста на наш. Тест проводился в преддверии новогодних праздников и совпал с другими маркетинговыми исследованиями. К тому же, фактическая дата окончания теста не совпадает с планируемой. Все это может негативно сказаться на результате тестирования новой платежной воронки. Фактически тест закончился раньше, чем предполагалось, это означает, что не все пользователи имеют четырнадцатидневный лайфтайм, что тоже может являться причиной искажения результатов. После всех проведенных фильтраций осталось 5925 уникальных пользователей, а ожидалось 6000. Плюс в группе В наблюдается больше количество пользователей, которые не совершали никаких событий после момента регистрации.
Распределение числа событий в выборках по дням.
ab_group['event_dt'] = ab_group['event_dt'].dt.date
ab_group.pivot_table(index = 'group', columns = 'event_dt', values = 'event_name', aggfunc = 'count').plot(kind = 'bar',
figsize = (10,6))
plt.title('Распределение числа событий в выборках по дням')
plt.xlabel('Группа')
plt.ylabel('Количество событий')
plt.legend(loc=1)
plt.show()
Количество событий в группе А намного больше, чем в группе В.
Построим воронку для изучения конверсий в группах.
conversion = (ab_group.pivot_table(index='event_name',
columns='group',
values='user_id',
aggfunc='nunique')
.sort_values(by = ['A', 'B'],ascending = False)
.reset_index())
conversion
| group | event_name | A | B |
|---|---|---|---|
| 0 | login | 2422 | 811 |
| 1 | product_page | 1571 | 461 |
| 2 | purchase | 773 | 228 |
| 3 | product_cart | 728 | 225 |
Мы видим, что в столбце с событиями не отображается момент регистрации, поэтому отдельно посчитаем конверсию в авторизацию.
ab_group.pivot_table(columns='group',values='user_id', aggfunc='nunique')
| group | A | B |
|---|---|---|
| user_id | 3385 | 2540 |
Добавим данные в таблицу конверсии.
conversion.loc[-1] = ['registration', 3385, 2540]
conversion.index = conversion.index +1
conversion = conversion.sort_index()
conversion = conversion.sort_values(by = ['A', 'B'],ascending = False)
conversion
| group | event_name | A | B |
|---|---|---|---|
| 0 | registration | 3385 | 2540 |
| 1 | login | 2422 | 811 |
| 2 | product_page | 1571 | 461 |
| 3 | purchase | 773 | 228 |
| 4 | product_cart | 728 | 225 |
На странице корзины оказалось меньше пользователей, чем тех, кто совершил покупку. Возможно, это обосновано тем, что есть функция быстрого заказа товара, где в корзину переходить необязательно.
Поправим таблицу конверсии и построим воронку.
new_index = [0, 1, 2, 4, 3]
conversion = conversion.reindex(new_index)
conversion
| group | event_name | A | B |
|---|---|---|---|
| 0 | registration | 3385 | 2540 |
| 1 | login | 2422 | 811 |
| 2 | product_page | 1571 | 461 |
| 4 | product_cart | 728 | 225 |
| 3 | purchase | 773 | 228 |
fig = go.Figure()
fig.add_trace(go.Funnel(name = 'Группа А',
y = conversion['event_name'],
x = conversion['A'],
textinfo = 'value+percent initial+percent previous'))
fig.add_trace(go.Funnel(name = 'Группа B',
y = conversion['event_name'],
x = conversion['B'],
textinfo = 'value+percent initial+percent previous'))
fig.update_layout(title_text='Воронка событий')
fig.show()
Воронка показывает, что в группе В очень маленькая конверсия в авторизацию, это можно объяснить большим количество неактивных пользователей. Наверное имеет смысл поискать какие-то технические ошибки, потому что у меня нет идей, почему такая плохая конверсия.
fig = go.Figure()
fig.add_trace(go.Funnel(name = 'Группа А',
y = conversion['event_name'].drop([0]),
x = conversion['A'].drop([0]),
textinfo = 'value+percent initial+percent previous'))
fig.add_trace(go.Funnel(name = 'Группа B',
y = conversion['event_name'].drop([0]) ,
x = conversion['B'].drop([0]),
textinfo = 'value+percent initial+percent previous'))
fig.update_layout(title_text='Воронка событий')
fig.show()
Если смотреть конверсию, начиная с авторизации и отбросить всех тех пользователей, которые после регистрации не совершали событий, то картина получается более оптимистичной. Но все же нет предполагаемого роста конверсии группы В, она даже меньше, чем в контрольной группе.
Посмотрим на распределение событий в группах.
ab_group.groupby('group').agg({'event_name':'count'}).plot(kind='bar', figsize=(10,5))
plt.title('Распределение количества событий по группам')
plt.xlabel('Группа')
plt.ylabel('Количество событий')
plt.show()
А также посмотрим на количество событий в разрезе каждого пользователя.
ab_group[ab_group['group'] == 'A'].groupby('user_id').agg({'event_name':'count'}).describe()
| event_name | |
|---|---|
| count | 3385.000000 |
| mean | 4.969276 |
| std | 4.517918 |
| min | 0.000000 |
| 25% | 0.000000 |
| 50% | 4.000000 |
| 75% | 8.000000 |
| max | 24.000000 |
ab_group[ab_group['group'] == 'B'].groupby('user_id').agg({'event_name':'count'}).describe()
| event_name | |
|---|---|
| count | 2540.000000 |
| mean | 1.786614 |
| std | 3.234339 |
| min | 0.000000 |
| 25% | 0.000000 |
| 50% | 0.000000 |
| 75% | 3.000000 |
| max | 24.000000 |
x = ab_group[ab_group['group'] == 'A'].groupby('user_id').agg({'event_name':'count'})
y = ab_group[ab_group['group'] == 'B'].groupby('user_id').agg({'event_name':'count'})
fig = plt.figure(figsize=(10,5))
plt.hist(x, label = 'группа A', alpha = 0.5)
plt.hist(y, label = 'группа B', alpha = 0.5)
plt.title('Распределение количества событий на каждого пользователя в группах')
plt.xlabel('Количество событий')
plt.ylabel('Количество вользователей')
plt.legend()
plt.show()
Мы видим, что события на пользователя в группах распределены неравномерно. В группе А пользователи совершили событий в среднем в 4 раза больше. Если смотреть в контексте каждого отдельного пользователя в группах, то в группе А каждый пользователь в среднем совершает в районе 4 событий, а в группе В - 2 события.
Попытаемся провести статистический тест на проверку равенства долей. Обозначим гипотезы.
Нулевая гипотеза: между долями разницы нет. Альтернативая: разница есть. Чтобы не увеличивать вероятность получения ошибки первого рода, применим поправку Бомферрони, разделив alpha на 3.
Обозначим некоторые особенности данного теста:
conversion
| group | event_name | A | B |
|---|---|---|---|
| 0 | registration | 3385 | 2540 |
| 1 | login | 2422 | 811 |
| 2 | product_page | 1571 | 461 |
| 4 | product_cart | 728 | 225 |
| 3 | purchase | 773 | 228 |
def z_test1(successes1, successes2, trials1, trials2, alpha=0.05/3):
p1 = successes1 / trials1
p2 = successes2 / trials2
p_combined = (successes1 + successes2) / (trials1 + trials2)
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if (p_value < alpha):
print('Отвергаем нулевую гипотезу, между выборками есть статистически значимые различия')
else:
print('Не получилось отвергнуть нулевую гипотезу, статистически значимых различий в выборках нет')
z_test1(conversion['A'][2], conversion['B'][2], conversion['A'][1], conversion['B'][1])
p-значение: 4.289834253490277e-05 Отвергаем нулевую гипотезу, между выборками есть статистически значимые различия
z_test1(conversion['A'][4], conversion['B'][4], conversion['A'][1], conversion['B'][1])
p-значение: 0.21088771560452946 Не получилось отвергнуть нулевую гипотезу, статистически значимых различий в выборках нет
z_test1(conversion['A'][3], conversion['B'][3], conversion['A'][1], conversion['B'][1])
p-значение: 0.04264690707174279 Не получилось отвергнуть нулевую гипотезу, статистически значимых различий в выборках нет
Тест показал, что в 2 из 3 проверок отвергнуть нулевую гипотезу о равенстве долей не вышло. Статистически значимых различий в выборках нет.
Значит нововведение повлияло на пользователей в рамках просмотра страницы продукта. В группе A - 65% конверсия, а в группе B - 57% и такое различие статистическо значимо по этому критерию. А в остальных случаях (этапах) существенной разницы в конверсии замечено не было. Т.е. нововведение ухудшило конверсию в просмотры товара, но на покупки в целом не повлияло.
В ходе исследования обнаружилось, что тест был проведен некорректно по нескольким параметрам:
Исследовательский анализ данных показал следующие результаты:
Статистические тесты показали, что между выборками в 2 из 3 случаев не существует значимых различий.
В результате можно сделать сделать вывод,что тестирование изменений, связанных с внедрением улучшенной рекомендательной системы, проходило некорректно. Пытаться хоть как-то интерпретировать полученные результаты будет неправильно.